Skip to content

RCSI 簡介與改良版 Entity Framework 鎖定提示攔截器

TLDR

  • RCSI (Read Committed Snapshot Isolation) 是解決高併發讀寫衝突的現代化標準做法,透過版本控制避免讀取被寫入阻塞。
  • RCSI 為資料庫層級設定,開啟後可取代大部分 WITH (NOLOCK) 的使用場景。
  • 若需精細控制 SQL Hint(如 NOLOCKOPTIMIZE FOR UNKNOWN),建議使用 EF Core 的 TagWith 搭配 DbCommandInterceptor 進行顯式宣告,而非全域強制注入。
  • 框架層級的自動化處理應以「降低團隊認知負荷」為核心,而非盲目追求技術純粹性。

RCSI 是什麼?

RCSI (Read Committed Snapshot Isolation) 透過「版本控制」解決鎖定問題。當資料被寫入時,讀取操作不會被阻塞,也不會讀到髒資料,而是讀取「寫入前的最後一個版本 (Snapshot)」。

RCSI 的特性與限制

  • 全域生效:RCSI 是資料庫層級設定,開啟後會影響所有使用 Read Committed 隔離層級的查詢。
  • 非全域開關:無法針對單一資料表開啟。若需選擇性使用,需改用 Snapshot Isolation 並在 Transaction 中顯式指定。
  • 無法取代樂觀鎖:RCSI 僅解決讀取阻塞問題,資料更新時的併發衝突仍需搭配 RowVersion 機制。

常見鎖定機制比較

  • WITH (NOLOCK):讀取可能包含尚未提交的「髒資料」。
  • WITH (READPAST):遇到鎖定行直接跳過,適用於 Queue 處理,不適合報表。
  • RCSI:讀取已提交的歷史快照,無需加 Hint 即可達成非阻塞讀取。

TIP

在現代硬體(SSD)與雲端環境(如 Azure SQL Database 預設開啟)下,RCSI 的 TempDB 負擔已不再是致命傷,是比到處加 NOLOCK 更乾淨的解法。

TagWith + Interceptor 改良實作

當專案不適合全域開啟 RCSI,或需針對特定查詢進行效能調校時,可利用 EF Core 的 TagWith 功能進行顯式宣告,透過攔截器動態注入 SQL Hint。

1. 定義擴充方法

透過 Fluent API 語法,讓程式碼意圖更清晰。

csharp
public static class EfHintExtensions {
    public const string TagNoLock = "SQL_HINT: NOLOCK";
    public const string TagReadPast = "SQL_HINT: READPAST";
    public const string TagOptimizeForUnknown = "SQL_OPTION: OPTIMIZE FOR UNKNOWN";
    public const string TagRecompile = "SQL_OPTION: RECOMPILE";

    public static IQueryable<T> WithNoLock<T>(this IQueryable<T> query) => query.TagWith(TagNoLock);
    public static IQueryable<T> WithReadPast<T>(this IQueryable<T> query) => query.TagWith(TagReadPast);
    public static IQueryable<T> WithOptimizeForUnknown<T>(this IQueryable<T> query) => query.TagWith(TagOptimizeForUnknown);
    public static IQueryable<T> WithRecompile<T>(this IQueryable<T> query) => query.TagWith(TagRecompile);
}

2. 實作攔截器 (Interceptor)

此攔截器支援 Schema 格式,並能自動合併多個 Query Option。

csharp
public class SqlTaggingInterceptor : DbCommandInterceptor {
    private static readonly RegexOptions regexOptions = RegexOptions.Multiline | RegexOptions.IgnoreCase;
    private static readonly Regex tableAliasRegex = new(
        @"(?<tableAlias>(?:FROM|JOIN)\s+(?:\[[^\]]+\]\.)?\[[^\]]+\]\s+AS\s+\[[^\]]+\])(?!\s+WITH\s*\()",
        regexOptions
    );

    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result
    ) {
        FixCommand(command);
        return base.ReaderExecuting(command, eventData, result);
    }

    private static void FixCommand(DbCommand command) {
        if (string.IsNullOrWhiteSpace(command.CommandText)) return;
        string text = command.CommandText;
        bool isChanged = false;

        string hintToApply = null;
        if (text.Contains(EfHintExtensions.TagNoLock)) hintToApply = "WITH (NOLOCK)";
        else if (text.Contains(EfHintExtensions.TagReadPast)) hintToApply = "WITH (READPAST)";

        if (hintToApply != null) {
            text = tableAliasRegex.Replace(text, $"${{tableAlias}} {hintToApply}");
            isChanged = true;
        }

        List<string> options = new ();
        if (text.Contains(EfHintExtensions.TagOptimizeForUnknown)) options.Add("OPTIMIZE FOR UNKNOWN");
        if (text.Contains(EfHintExtensions.TagRecompile)) options.Add("RECOMPILE");

        if (options.Count > 0) {
            text = text.TrimEnd().TrimEnd(';');
            text += $" OPTION ({string.Join(", ", options)});";
            isChanged = true;
        }

        if (isChanged) command.CommandText = text;
    }
}

實際使用範例

csharp
// 報表:允許髒讀 + 解決參數嗅探
List<Order> orders = context.Orders
    .WithNoLock()
    .WithOptimizeForUnknown()
    .ToList();

// Queue:跳過鎖定行
Job job = context.Jobs
    .WithReadPast()
    .FirstOrDefault();

TIP

本篇完整可執行範例請參考:CloudyWing/EfCoreSqlHintInterceptorSample

結論與建議

  • 優先採用 RCSI:在讀多寫少的系統中,RCSI 是最乾淨的預設解法,能從根本解決讀寫衝突。
  • 顯式宣告優於隱式注入:使用 TagWith 進行顯式宣告,能讓開發者清楚掌握哪些查詢使用了特殊的 SQL Hint,降低維護風險。
  • 架構設計考量:全域處理(如 AsNoTracking 或攔截器)的價值在於降低團隊的認知負荷。應評估專案情境,選擇「預設行為」與「顯式介入」之間的平衡點,而非盲目排斥框架層級的自動化。

異動歷程

    • 初版文件建立。
    • 將擴充方法 WithOptionUnknown 更名為 WithOptimizeForUnknown,與範例專案一致。
    • 補上對應 GitHub 範例專案連結。